// Copyright 2010 Google Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.


/**
 * @fileoverview The ScriptSet class manages a set of Scripts, see
 * shellExtension.Script.  The purpose of this class is to remove redundant
 * code from higher level structures such as the BackgroundScript and
 * ContentScript, and to provide a specific method of access to Script data
 * from those higher levels.  To achieve this goal, the class can have multiple
 * scripts both JavaScript and CSS added to it.  However, scripts can only be
 * retrieved as a blob.  The blob is an object containing a mapping for
 * JavaScript and CSS code where each blob entry is a string containing the
 * concatenation of all the related scripts.  The order in which the scripts
 * are concatenated within the blob is the same as the order they were added to
 * the set.
 *
 * Scripts are managed by a series of references that specify the state of
 * each Script and the overall status of the set.  Whenever a new Script
 * would be created but fails then the set records that information and tracks
 * how many times this happens.  A single failure will render the entire set
 * unusable until resolved.  Resolution currently is to clear the set and
 * reload all the scripts; reconstructing the set.
 *
 * All scripts that are loadable are put into a pending list.  When the request
 * for the glob occurs it will not return the glob if there are still pending
 * Scripts.  To resolve pending Scripts, there is a function to retrieve a
 * list of pending scripts called getScriptDataHandlers that returns a list
 * of objects the contains the location of the script code and a callback that
 * acts like a set function for a Script; creating and adding a new script.  In
 * this way it is left to the user to determine how to request the script data.
 * No XMLHTTPRequests occur at this level.  Instead the upper level objects are
 * responsible for feeding data to the set, but the set determines how to give
 * data back to users.
 *
 * Public interface:
 *   shellExtension.ScriptSet() - Constructs a new ScriptSet; see file
 *       overview.
 *
 * Public prototype functions for shellExtension.ScriptSet.
 *   add(id, type, log, opt_data) - Adds a new script to the set.  This
 *       function accepts pure code or the location (url) of the code.  Returns
 *       whether or not the script was successfully added.  Performs validation
 *       as data may come from an external source.
 *   clear() - Clear the entire set and resetting the instance.
 *   clearData() - Clear all loaded scripts and set them to pending.  If at
 *       least one script was reset then clear the blob.  Also, do nothing if
 *       the set is in the failed state.
 *   getGlobs() - Return the CSS and JavaScript blobs in an object.
 *   getDetailList() - Return an array of
 *       shellExtension.data.ScriptDetails for all loadable scripts.
 *   getScriptDataHandlers() - Returns an array of objects containing the url
 *       to retrieve the script and a handler for setting the corresponding
 *       script's data.
 *   getStatus() - Returns a heirarchical status for the entire set where the
 *       priorities are from highest to lowest: FAILED, INIT, and LOADED.
 *   remove(id)- Removes a loadable script.
 *
 * @author jasonstredwick@google.com (Jason Stredwick)
 */


goog.provide('shellExtension.ScriptSet');

goog.require('shellExtension.Script');
goog.require('shellExtension.data');
goog.require('shellExtension.manifest.script');
goog.require('shellExtension.status');



/**
 * The script class encapsulates information about a set of scripts.
 * @constructor
 */
shellExtension.ScriptSet = function() {
  /**
   * The number of times add failed.
   * @type {number}
   * @private
   */
  this.addFailed_ = 0;

  /**
   * The glob object allows data for each script to be pooled together into
   * one unit.  The glob only pools like data with the key being the type, @see
   * shellExtension.manifest.script.Property.  The ScriptSet only uses JS_CODE
   * and CSS_CODE.  All other code sources must be converted before adding.
   * @type {?Object.<string>}
   * @private
   */
  this.glob_ = null;

  /**
   * A script id to script reference map.
   * @type {!Object.<string, shellExtension.Script>}
   * @private
   */
  this.scriptsById_ = {};

  /**
   * An array to hold all the scripts in the set.
   * @type {!Array.<shellExtension.Script>}
   * @private
   */
  this.scripts_ = [];

  /**
   * This array holds a reference to all the scripts that are not completely
   * loaded; i.e. pending load.
   * @type {!Array.<shellExtension.Script>}
   * @private
   */
  this.scriptsPending_ = [];
};


/**
 * Creates a new script and adds it to the set.  If the script already exists
 * then nothing happens.  However, pure scripts are not checked for
 * duplication.  If the inputs are invalid then no Script is created and
 * addFailed is marked.  Also, as some of the data can come from external
 * sources, part of the data are validated.
 * @param {?string} id The id of the script, or null if it is pure code.
 * @param {shellExtension.manifest.script.Property} type The type of script.
 * @param {!bite.common.log.Simple} log The log to record errors.
 * @param {string=} opt_data Optional data contains code when the type
 *     is pure code.  When the type is a url then this parameter is ignored.
 * @return {boolean} Whether or not the script was added successfully.
 */
shellExtension.ScriptSet.prototype.add = function(id, type, log, opt_data) {
  var isUrl; // Set during validation.

  // Validate inputs.
  try {
    if (!shellExtension.manifest.script.validateType(type)) {
      throw 'Invalid script type (' + type + ').';
    }

    switch (type) {
      case shellExtension.manifest.script.Property.CSS_CODE:
      case shellExtension.manifest.script.Property.JS_CODE:
        opt_data = opt_data || '';
        if (!shellExtension.manifest.script.validate(opt_data)) {
          throw 'Invalid script data (' + opt_data + ').';
        }
        isUrl = false;
        break;

      case shellExtension.manifest.script.Property.CSS_URL:
      case shellExtension.manifest.script.Property.JS_URL:
        if (!id || !shellExtension.manifest.script.validateId(id)) {
          throw 'Invalid url (' + id + ').';
        }
        isUrl = true;
        break;
    }
  } catch (error) {
    ++this.addFailed_; // Mark failure.
    log.add('ERROR (shellExtension.ScriptSet.add): ' + error);
    return false;
  }

  // Ignore the given script if it is already added.
  if (id && id in this.scriptsById_) {
    return true;
  }

  var value = shellExtension.status.Value;

  // Create a new Script and set it's parameters.
  var script = new shellExtension.Script();
  script.type = type;
  script.id = isUrl && id ? id : null;
  script.data = isUrl ? null : (opt_data ? opt_data + '\n' : '');
  script.status = isUrl ? value.INIT : value.LOADED;
  this.scripts_.push(script);

  // The glob must be deleted so that the new script will be inserted
  // correctly into the glob once it is loaded.
  this.glob_ = null;

  return true;
};


/**
 * Clears the set; basically resets the set to its initial state.
 */
shellExtension.ScriptSet.prototype.clear = function() {
  this.addFailed_ = 0;
  this.glob_ = null;
  this.scriptsById_ = {};
  this.scripts_ = [];
  this.scriptsPending_ = [];
};


/**
 * Clears all Scripts' data and sets the glob to null if there is now pending
 * scripts.  Scripts that are pure code are ignored.  Nothing is done if the
 * set is in a failed state.
 */
shellExtension.ScriptSet.prototype.clearData = function() {
  // If the set is in a failed state, do nothing.
  if (this.addFailed_) {
    return;
  }

  // Clear the pending list in order to repopulate it.
  this.scriptsPending_ = [];

  for (var i = 0, len = this.scripts_.length; i < len; ++i) {
    var script = this.scripts_[i];

    // Ignore pure code scripts.
    if (script.type == shellExtension.manifest.script.Property.CSS_CODE ||
        script.type == shellExtension.manifest.script.Property.JS_CODE) {
      continue;
    }

    // Clear the script data, set its state back to INIT, and add to pending.
    script.data = null;
    script.status = shellExtension.status.Value.INIT;
    this.scriptsPending_.push(script);
  }

  if (this.scriptsPending_.length) {
    // Clear the glob only if there are any pending scripts.
    this.glob_ = null;
  }
};


/**
 * Retrieves an array of objects that detail each loadable script in the set.
 * The details include id, status, and type.
 * @return {!Array.<!shellExtension.data.ScriptDetails>} An array of
 *     script detail objects.
 */
shellExtension.ScriptSet.prototype.getDetailList = function() {
  var output = [];

  var createScriptDetails = shellExtension.data.createScriptDetails;
  for (var i = 0, len = this.scripts_.length; i < len; ++i) {
    var script = this.scripts_[i];
    var type =
        /** @type {shellExtension.manifest.script.Property} */ (script.type);

    if (script.type == shellExtension.manifest.script.Property.CSS_CODE ||
        script.type == shellExtension.manifest.script.Property.JS_CODE) {
      continue;
    }

    output.push(createScriptDetails(script.id, script.status, type));
  }

  return output;
};


/**
 * Gathers script code into single strings by type and returns an object with
 * those strings accessible by ids corresponding to their type.  If not all the
 * scripts are loaded then null is returned.
 * @param {!bite.common.log.Simple} log The log to record errors.
 * @return {Object.<string>} Returns an object containing code joined into
 *     single strings based on type.  The blobs are only represented as raw
 *     code from shellExtension.manifest.script.Property.
 */
shellExtension.ScriptSet.prototype.getGlobs = function(log) {
  if (this.addFailed_ || this.scriptsPending_.length) {
    return null;
  }

  if (!this.glob_) {
    this.globData_(log);
  }

  return this.glob_;
};


/**
 * Composes an array of objects that contain an id and callback for each
 * pending script in the set.  The id corresponds to the location from which
 * the script can be retrieved, and the callback is a setter function for the
 * script that takes a string with the code or null if there is an error.  The
 * format of the set function is function(code: ?string).  If the script set is
 * in a failed state then return an empty array.
 *
 * Only failed additions prevent scripts from being loaded.  Pending scripts
 * that failed or are in a bad state are allowed to be reloaded.
 * @param {!bite.common.log.Simple} log The log to record errors.
 * @return {Array.<!{url: string, callback: function(?string)}>} An array of
 *     objects corresponding to pending script.  Each object contains where
 *     to get the script's code and a set function.
 */
shellExtension.ScriptSet.prototype.getScriptDataHandlers = function(log) {
  if (this.addFailed_) {
    return [];
  }

  var output = [];

  var me = this;
  for (var i = 0, len = this.scriptsPending_.length; i < len; ++i) {
    var script = this.scriptsPending_[i];

    var callback = function(code) {
      me.setData_(log, script, code);
    };

    output.push({url: script.id, callback: callback});
  }

  return output;
};


/**
 * Retrieves status of the set.
 * @return {shellExtension.status.Value} The status.
 */
shellExtension.ScriptSet.prototype.getStatus = function() {
  // Scripts that are pending and fail to load are left as pending.
  var badScripts = false;
  for (var i = 0, len = this.scriptsPending_.length; i < len; ++i) {
    var status = this.scriptsPending_[i].status;
    if (status == shellExtension.status.Value.BAD_OBJECT ||
        status == shellExtension.status.Value.FAILED) {
      badScripts = true;
      break;
    }
  }

  if (this.addFailed_ || badScripts) {
    return shellExtension.status.Value.FAILED;
  } else if (this.scriptsPending_.length) {
    return shellExtension.status.Value.INIT;
  }
  return shellExtension.status.Value.LOADED;
};


/**
 * Gathers all the code and constructs the glob.
 * @param {!bite.common.log.Simple} log The log to record errors.
 * @private
 */
shellExtension.ScriptSet.prototype.globData_ = function(log) {
  this.glob_ = {};
  this.glob_[shellExtension.manifest.script.Property.JS_CODE] = '';
  this.glob_[shellExtension.manifest.script.Property.CSS_CODE] = '';

  for (var i = 0, len = this.scripts_.length; i < len; ++i) {
    var script = this.scripts_[i];
    var type = script.type;
    var code = script.data;

    if (!code) {
      continue;
    }

    // Type is always validated when added.
    switch (type) {
      case shellExtension.manifest.script.Property.CSS_CODE:
      case shellExtension.manifest.script.Property.CSS_URL:
        this.glob_[shellExtension.manifest.script.Property.CSS_CODE] += code;
        break;

      case shellExtension.manifest.script.Property.JS_CODE:
      case shellExtension.manifest.script.Property.JS_URL:
        this.glob_[shellExtension.manifest.script.Property.JS_CODE] += code;
        break;
    }
  }
};


/**
 * If the specified script exists then remove it from the list of scripts.
 * Removing a script does not change the set status, but does change the glob
 * as the code may reside in the glob as well.
 *
 * Note: I am not sure this function should exist.  If it should then how to
 * handle pure code removal?
 * @param {string} id The id of the script.
 */
shellExtension.ScriptSet.prototype.remove = function(id) {
  if (id && id in this.scriptsById_) {
    delete this.scriptsById_[id];

    var filter = function(script) { return script.id != id; };
    this.scriptsPending_ = this.scriptsPending_.filter(filter);
    this.scripts_ = this.scripts_.filter(filter);
  }
};


/**
 * A handler that is called once the data has been retrieved for the given
 * script.  It sets the scripts data and then sets the status to loaded.  If
 * there was an error then the data should be null and the script's status set
 * to FAILED.  Also, this function performs validation as its data may come
 * from an external source.
 * @param {!bite.common.log.Simple} log The log to record errors.
 * @param {shellExtension.Script} script The script to update.
 * @param {?string} data The data loaded for this script or null if no data was
 *     retrieved.
 * @private
 */
shellExtension.ScriptSet.prototype.setData_ = function(log, script, data) {
  // The data comes from an external source and the types are not validated.
  if (!data || !shellExtension.manifest.script.validate(data)) {
    log.add('ERROR (shellExtension.ScriptSet.setData_): Failed to ' +
            'retrieve data for script.');
    script.data = null;
    script.status = shellExtension.status.Value.FAILED;
    return;
  }

  script.data = data;
  script.status = shellExtension.status.Value.LOADED;

  var filter = function(script_) { return script_.id != script.id; };
  this.scriptsPending_ = this.scriptsPending_.filter(filter);
};

